﻿#region

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Fasterflect;

#endregion

namespace DeepCompare
{
    /// <summary>
    ///     Comparer for deep comparing objects.
    /// </summary>
    public class DeepComparer
    {
        protected string ERR_CHECK_USING_EQUALITY_FAILED = "Equality check fails, Expected: <{0}>, Actual: <{1}>";

        protected string ERR_DIFFERENT_ELEMENT_COUNT = "The two lists or array contain different count of elements.";

        /// <summary>
        ///     The current member path in the actual comparison context.
        /// </summary>
        private List<string> _currentPath;

        /// <summary>
        ///     Initializes a new instance of the <see cref="DeepComparer" /> class.
        /// </summary>
        public DeepComparer()
        {
            Reinitialize();
        }

        /// <summary>
        ///     Initializes a new instance of the <see cref="DeepComparer" /> class.
        /// </summary>
        /// <param name="treatStructureDifferencesOnNullPropertiesAsWarnings">
        ///     if set to <c>true</c>, the comparer will the comparer treat structure differences on null properties as warnings. This parameter sets the property
        ///     <see
        ///         cref="TreatStructureDifferencesOnNullPropertiesAsWarnings" />
        ///     .
        /// </param>
        public DeepComparer(bool treatStructureDifferencesOnNullPropertiesAsWarnings)
            : this()
        {
            TreatStructureDifferencesOnNullPropertiesAsWarnings = treatStructureDifferencesOnNullPropertiesAsWarnings;
        }

        /// <summary>
        ///     Indicates wherever the comparer treat structure differences on null properties as warnings. Standard value is false.
        /// </summary>
        public bool TreatStructureDifferencesOnNullPropertiesAsWarnings { get; set; }

        /// <summary>
        ///     Gets or sets the unequality error messages which are defining which parts are not equal.
        /// </summary>
        /// <value>
        ///     The unequality.
        /// </value>
        protected List<DifferenceEntry> Differences { get; set; }

        /// <summary>
        ///     Gets or sets the warnings.
        /// </summary>
        /// <value>
        ///     The warnings.
        /// </value>
        protected List<DifferenceEntry> Warnings { get; set; }

        /// <summary>
        ///     Gets the differences of the last compare run. If you dont have called compare before, an exception will be thrown.
        /// </summary>
        /// <returns>The differences of the last Compare() run.</returns>
        /// <exception cref="System.NullReferenceException">No differences where matched because Compare() was not called before.</exception>
        public DifferenceEntry[] GetDifferences()
        {
            if(Differences == null)
                throw new NullReferenceException("No differences where matched because Compare() was not called before.");
            return Differences.ToArray();
        }

        /// <summary>
        ///     Gets the warnings.
        /// </summary>
        /// <returns></returns>
        /// <exception cref="System.NullReferenceException">No warnings where matched because Compare() was not called before.</exception>
        public DifferenceEntry[] GetWarnings()
        {
            if(Warnings == null)
                throw new NullReferenceException("No warnings where matched because Compare() was not called before.");
            return Warnings.ToArray();
        }

        /// <summary>
        ///     Determines whether this instance has differences.
        /// </summary>
        /// <returns>
        ///     <c>true</c> if this instance has differences; otherwise, <c>false</c>.
        /// </returns>
        /// <exception cref="System.NullReferenceException">No differences where matched because Compare() was not called before.</exception>
        public bool HasDifferences()
        {
            if(Differences == null)
                throw new NullReferenceException("No differences where matched because Compare() was not called before.");
            return Differences.Any();
        }

        /// <summary>
        ///     Determines whether this instance has warnings.
        /// </summary>
        /// <returns>
        ///     <c>true</c> if this instance has warnings; otherwise, <c>false</c>.
        /// </returns>
        /// <exception cref="System.NullReferenceException">No warnings where matched because Compare() was not called before.</exception>
        public bool HasWarnings()
        {
            if(Warnings == null)
                throw new NullReferenceException("No warnings where matched because Compare() was not called before.");
            return Warnings.Any();
        }

        /// <summary>
        ///     Reinitializes this instance for preparing for a new check.
        /// </summary>
        protected void Reinitialize()
        {
            Differences = new List<DifferenceEntry>();
            Warnings = new List<DifferenceEntry>();
            _currentPath = new List<string>
            {
                "Object"
            };
        }

        /// <summary>
        ///     Gets the humand readable unequalities.
        /// </summary>
        /// <returns></returns>
        protected string GetHumandReadableUnequalities()
        {
            var sb = new StringBuilder();
            if(Differences != null && Differences.Count > 0)
            {
                Differences.ForEach(ueq => sb.AppendFormat("{0}\r\n         {1}\r\n",
                    ueq.Path,
                    ueq.Message));
            }
            return sb.ToString();
        }

        /// <summary>
        ///     Compares the two objects in depth and returns the state of equality.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <returns></returns>
        public bool Compare(object objectOne,
            object objectTwo)
        {
            // New compare run
            Reinitialize();
            CompareInternal(objectOne,
                objectTwo);
            return !Differences.Any();
        }

        /// <summary>
        ///     Returns a <see cref="System.String" /> that represents this instance.
        /// </summary>
        /// <returns>
        ///     A <see cref="System.String" /> that represents this instance.
        /// </returns>
        public override string ToString()
        {
            return Differences == null
                ? "Compare() was not called"
                : Differences.Count > 0
                    ? "Differences where found: \r\n" + GetHumandReadableUnequalities()
                    : "No differences where found";
        }

        /// <summary>
        ///     Compares the objects protectedly, this method is an recursive used method.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        protected void CompareInternal(
            object objectOne,
            object objectTwo)
        {
            // Check both are null
            if((objectOne == null || objectOne == objectOne.GetType().GetDefaultValue()) &&
                (objectTwo == null || objectTwo == objectTwo.GetType().GetDefaultValue()))
                return;
            // Check if something is null while the other not
            if(!ContinueAfterCheckIfOneIsNullAndTheOtherNot(objectOne,
                objectTwo))
                return;
            // Compare the both objects with the help of the Equals() method when it was declared directly in the given type.
            if(!ContinueAfterCheckWithOwnEqualMethod(objectOne,
                objectTwo))
                return;
            // If no check for non standard types needed, end this here
            if(CheckUsingStandardTypesSuccess(objectOne,
                objectTwo))
                return;
            // Now check for array and lists
            if(CheckArraysAndListsWasDone(objectOne,
                objectTwo))
                return;
            // If nothing matched until here, check deep in the method structure
            //CheckMemberEntitiesForOccurance(objectOne, objectTwo);
            CheckMembers(objectOne,
                objectTwo);
        }

        /// <summary>
        ///     Checks for arrays and lists.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <returns></returns>
        protected bool CheckArraysAndListsWasDone(object objectOne,
            object objectTwo)
        {
            if(objectOne.GetType().ContainsSetOfElements() && objectTwo.GetType().ContainsSetOfElements())
            {
                var listOne = GetArrayFromIEnumerable((IEnumerable) objectOne).ToList();
                var listTwo = GetArrayFromIEnumerable((IEnumerable) objectTwo).ToList();
                if(!AssertIsTrue(() => listOne.Count == listTwo.Count,
                    ERR_DIFFERENT_ELEMENT_COUNT))
                    return true;
                var takeElementsForCompare = listOne.Count > listTwo.Count ? listTwo.Count : listOne.Count;
                for(var i = 0; i < takeElementsForCompare; i++)
                {
                    _currentPath.Add("[" + i + "]");

                    var itemOne = listOne[i];
                    var itemTwo = listTwo[i];
                    if(IsEnumValue(itemOne) || IsEnumValue(itemTwo))
                    {
                        itemOne = GetEnumValue(itemOne);
                        itemTwo = GetEnumValue(itemTwo);
                    }
                    CompareInternal(itemOne,
                        itemTwo);
                    _currentPath.RemoveLastItem();
                }
                return true;
            }
            return false;
        }

        /// <summary>
        /// Determines whether the given member on the object is an <see cref="Enum"/>.
        /// </summary>
        /// <param name="memberName">Name of the member.</param>
        /// <param name="obj">The obj.</param>
        /// <returns>
        ///   <c>true</c> if [is enum member value] [the specified member name]; otherwise, <c>false</c>.
        /// </returns>
        protected bool IsEnumMemberValue(string memberName,
            object obj)
        {
            var val = GetMemberValue(memberName,
                obj);
            return val!=null ? IsEnumValue(val) : false;
        }

        /// <summary>
        /// Determines whether the given object is an enum value.
        /// </summary>
        /// <param name="val">The val.</param>
        /// <returns>
        ///   <c>true</c> if [is enum value] [the specified val]; otherwise, <c>false</c>.
        /// </returns>
        protected bool IsEnumValue(object val)
        {
            return val.GetType().GetBaseTypes().Contains(typeof(Enum));
        }

        /// <summary>
        /// Gets the underlying enum value.
        /// </summary>
        /// <param name="enumValue">The enum value.</param>
        /// <returns></returns>
        protected object GetEnumValue(object enumValue)
        {
            return Convert.ChangeType(enumValue, Enum.GetUnderlyingType(enumValue.GetType()));
        }
        /// <summary>
        ///     Gets the array from the given IEnumerable.
        /// </summary>
        /// <param name="obj">The obj.</param>
        /// <returns></returns>
        protected IEnumerable<object> GetArrayFromIEnumerable(IEnumerable obj)
        {
            var en = obj.GetEnumerator();
            while(en.MoveNext())
                yield return en.Current;
        }

        /// <summary>
        ///     Checks the using standard types.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <returns></returns>
        protected bool CheckUsingStandardTypesSuccess(object objectOne,
            object objectTwo)
        {
            if(CheckForStandardTypesThatAreFromSameType(objectOne.GetType(),
                objectTwo.GetType()))
            {
                AssertIsTrue(() => (objectOne.ToString() == objectTwo.ToString()),
                    string.Format(ERR_CHECK_USING_EQUALITY_FAILED,
                        objectOne,
                        objectTwo));
                return true;
            }
            return false;
        }

        /// <summary>
        ///     Checks the members for occurance.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <returns></returns>
        protected void CheckMembers(object objectOne,
            object objectTwo)
        {
            var alreadyComparedMembers = new List<string>();
            CheckFieldsAndPropertiesAgainMembersOfObjectTwo(objectOne,
                objectTwo,
                alreadyComparedMembers,
                ComparerSide.LeftHand);
            CheckFieldsAndPropertiesAgainMembersOfObjectTwo(objectTwo,
                objectOne,
                alreadyComparedMembers,
                ComparerSide.RightHand);
        }

        /// <summary>
        ///     Checks the fields and properties again members of object two.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <param name="alreadyComparedMembers">The compared members.</param>
        /// <param name="currentSide">The current side from where the comparing was run.</param>
        private void CheckFieldsAndPropertiesAgainMembersOfObjectTwo(object objectOne,
            object objectTwo,
            IList<string> alreadyComparedMembers,
            ComparerSide currentSide)
        {
            var memberNames = GetAllMemberNames(objectOne);
            var memberNamesRightHand = GetAllMemberNames(objectTwo);
            var notFoundMembers = memberNames.Where(mn => !memberNamesRightHand.Contains(mn));
            foreach(var notFoundMember in notFoundMembers)
            {
                _currentPath.Add(notFoundMember);
                var currentMemberValue = GetMemberValue(notFoundMember,
                    objectOne);
                AddUnequalityMessage(
                    string.Format("Member <{0}> was not found on {1}, Actual type: <{2}>, Type where expected: <{3}>",
                        notFoundMember,
                        currentSide,
                        objectOne.GetType().Name,
                        objectTwo.GetType().Name),
                    // Treat as warning? Yes if the member value is not default value
                    TreatStructureDifferencesOnNullPropertiesAsWarnings && !(currentMemberValue != null &&
                        GetMemberType(objectOne.GetType(),
                            notFoundMember).GetDefaultValue() != currentMemberValue));
                _currentPath.RemoveLastItem();
            }
            var foundMembers = memberNames.Where(mn => memberNamesRightHand.Contains(mn));
            foreach(var foundMember in foundMembers)
            {
                _currentPath.Add(foundMember);
                var pathName = GetPathName();
                if(!alreadyComparedMembers.Contains(pathName))
                {
                    alreadyComparedMembers.Add(pathName);
                    var memberValueOne = GetMemberValue(foundMember,
                        objectOne);
                    var memberValueTwo = GetMemberValue(foundMember,
                        objectTwo);
                    if(IsEnumMemberValue(foundMember,
                        objectOne) || IsEnumMemberValue(foundMember, objectTwo))
                    {
                        memberValueOne = GetEnumValue(memberValueOne);
                        memberValueTwo = GetEnumValue(memberValueTwo);
                    }
                    CompareInternal(memberValueOne,
                        memberValueTwo);
                }
                _currentPath.RemoveLastItem();
            }
        }

        /// <summary>
        ///     Compares the members (fields, properties) by the given name.
        /// </summary>
        /// <typeparam name="TObjectOne">The type of the object one.</typeparam>
        /// <typeparam name="TObjectTwo">The type of the object two.</typeparam>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <param name="memberName">Name of the member.</param>
        protected void CompareByMemberName<TObjectOne, TObjectTwo>(TObjectOne objectOne,
            TObjectTwo objectTwo,
            string memberName)
        {
            var valueOne = GetMemberValue(memberName,
                objectOne);
            var valueTwo = GetMemberValue(memberName,
                objectTwo);
            AssertIsTrue(() =>
            {
                var lastErrorCount = Differences.Count;
                CompareInternal(valueOne,
                    valueTwo);
                return lastErrorCount == Differences.Count;
            },
                string.Format("Equality fails: Expected: <{0}>, Actual: <{1}>",
                    valueOne,
                    valueTwo));
        }

        /// <summary>
        ///     Gets the value of the member (field, property) with the given name.
        /// </summary>
        /// <param name="memberName">Name of the member.</param>
        /// <param name="objectForMemberGet">The object for member get.</param>
        /// <returns></returns>
        protected object GetMemberValue(string memberName,
            object objectForMemberGet)
        {
            var type = objectForMemberGet.GetType();
            if(type.GetProperty(memberName) != null)
                return objectForMemberGet.GetPropertyValue(memberName);
            if(type.GetField(memberName) != null)
                return objectForMemberGet.GetFieldValue(memberName);
            return null;
        }

        /// <summary>
        ///     Gets the type of the member.
        /// </summary>
        /// <param name="type">The type.</param>
        /// <param name="memberName">Name of the member.</param>
        /// <returns></returns>
        protected Type GetMemberType(Type type,
            string memberName)
        {
            var propertyInfo = type.GetProperty(memberName);
            if(propertyInfo != null)
                return propertyInfo.Type();
            var fieldInfo = type.GetField(memberName);
            if(fieldInfo != null)
                return fieldInfo.Type();
            return null;
        }

        /// <summary>
        ///     Gets all member names.
        /// </summary>
        /// <param name="obj">The obj.</param>
        /// <returns></returns>
        protected List<string> GetAllMemberNames(object obj)
        {
            var membersOne = new List<string>(
                obj.GetType()
                   .GetProperties()
                   .Select(prop => prop.Name));
            membersOne.AddRange(
                obj.GetType()
                   .GetFields()
                   .Select(field => field.Name));
            return membersOne;
        }

        /// <summary>
        ///     Gets the element count of the enumeration.
        /// </summary>
        /// <param name="listOne">The list one.</param>
        /// <returns></returns>
        protected static int GetEnumerationElementCount(IEnumerable listOne)
        {
            var count = 0;
            var enumeratorOne = listOne.GetEnumerator();
            while(enumeratorOne.MoveNext()) count++;
            return count;
        }

        /// <summary>
        ///     Checks the type of for non standard.
        /// </summary>
        /// <returns></returns>
        protected bool CheckForStandardTypesThatAreFromSameType(Type typeOne,
            Type typeTwo)
        {
            return typeOne.FullName == typeTwo.FullName && IsStandardType(typeOne);
        }

        /// <summary>
        ///     Checks standard types with equality method.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <returns>This method returns true, if the comparison workflow has to continue or if this is not needed anymore.</returns>
        protected bool ContinueAfterCheckWithOwnEqualMethod(
            object objectOne,
            object objectTwo)
        {
            var continueWorkflow = ContinueAfterCheckWithOwnEqualityMethodBySide(objectOne,
                objectTwo);
            if(continueWorkflow)
            {
                continueWorkflow = ContinueAfterCheckWithOwnEqualityMethodBySide(objectTwo,
                    objectOne);
            }
            return continueWorkflow;
        }

        /// <summary>
        ///     Continues the after check with own equality method by side.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <returns></returns>
        public bool ContinueAfterCheckWithOwnEqualityMethodBySide(object objectOne,
            object objectTwo)
        {
            var typeOne = objectOne.GetType();
            var methods =
                typeOne.GetMethods()
                       .Where(meth => meth.DeclaringType != null && (meth.DeclaringType.FullName == typeOne.FullName && meth.Name == "Equals")).ToList();
            var equalityFound = false;
            if(methods.Any())
            {
                methods
                    .ForEach(
                        meth =>
                        {
                            try
                            {
                                var areEqual = (bool) meth.Invoke(objectOne,
                                    new[]
                                    {
                                        objectTwo
                                    });
                                if(areEqual)
                                    equalityFound = true;
                            }
                            catch
                            {
                            }
                        });
                if(equalityFound) return false;
            }
            return true;
        }

        /// <summary>
        ///     Determines whether the given object is a standard type.
        /// </summary>
        /// <returns>
        ///     <c>true</c> if [is standard type] [the specified obj]; otherwise, <c>false</c>.
        /// </returns>
        protected bool IsStandardType(Type type)
        {
            return Settings.StandardAssertTypes.ToList()
                           .Contains(type.FullName);
        }

        /// <summary>
        ///     Asserts the specified expression, if it returns false the unequality message for the given path will added to the unequality message list.
        /// </summary>
        /// <param name="expression">The expression.</param>
        /// <param name="messageOnFail">The message on fail.</param>
        /// <returns>The return value of the expression.</returns>
        protected bool AssertIsTrue(Func<bool> expression,
            string messageOnFail)
        {
            if(expression()) return true;
            AddUnequalityMessage(messageOnFail);
            return false;
        }

        /// <summary>
        ///     Checks the null equality which means that it checks if one of the object is null while the other one is not null.
        /// </summary>
        /// <param name="objectOne">The object one.</param>
        /// <param name="objectTwo">The object two.</param>
        /// <returns></returns>
        protected bool ContinueAfterCheckIfOneIsNullAndTheOtherNot<TObjectOne, TObjectTwo>(TObjectOne objectOne,
            TObjectTwo objectTwo)
            where TObjectTwo : class
            where TObjectOne : class
        {
            if(!AssertIsTrue(() => !(objectOne == default(TObjectOne) && objectTwo != default(TObjectTwo)),
                "Object left hand (" + typeof(TObjectOne).FullName +
                    ") is of default value but on left hand (" + typeof(TObjectTwo).FullName + ") it is not."))
                return false;
            if(!AssertIsTrue(() => !(objectTwo == default(TObjectTwo) && objectOne != default(TObjectOne)),
                "Object right hand (" + typeof(TObjectTwo).FullName +
                    ") is of default value but on left hand (" + typeof(TObjectOne).FullName + ") it is not."))
                return false;
            return true;
        }

        /// <summary>
        ///     Adds a unequality message.
        /// </summary>
        /// <param name="message">The message.</param>
        /// <param name="isWarning">Inidicates wherever this message is a warning or not.</param>
        protected void AddUnequalityMessage(string message,
            bool isWarning = false)
        {
            if(!isWarning)
            {
                Differences.Add(new DifferenceEntry(GetPathName(),
                    message));
            }
            else
            {
                Warnings.Add(new DifferenceEntry(GetPathName(),
                    message));
            }
        }

        /// <summary>
        ///     Gets the name of the current comparer path.
        /// </summary>
        /// <returns></returns>
        protected string GetPathName()
        {
            var sb = new StringBuilder();
            for(var i = 0; i < _currentPath.Count; i++)
            {
                if(i > 0 && !_currentPath[0].StartsWith("[") && !_currentPath[0].EndsWith("]"))
                    sb.Append(".");
                sb.Append(_currentPath[i]);
            }
            return sb.ToString();
        }
    }
}